<?php

/**
 * Acl rules builder
 *
 * @author     Mon Zafra <monzee at gmail>
 * @copyright  (c)2009 Mon Zafra
 * @category   Mz
 * @package    modules
 * @subpackage admin
 * @license    http://mz-project.googlecode.com/svn/trunk/LICENSE    MIT License
 * @version    SVN: $Id: AccessManager.php 47 2009-05-28 17:56:56Z monzee $
 */
class Admin_Helper_AccessManager extends Zend_Controller_Action_Helper_Abstract
{
    protected $_enabled = true;
    /**
     * Acl object
     *
     * @var Zend_Acl
     */
    protected $_acl;

    protected $_aclModel;
    protected $_aclModelClass = 'Admin_Model_Acl';
    protected $_aclModelParams = array();

    protected $_loginPage = array(
        'module' => 'admin',
        'controller' => 'user',
        'action' => 'login',
    );
    protected $_deniedPage = array(
        'module' => 'admin',
        'controller' => 'error',
        'action' => 'denied',
    );
    protected $_guestRole = 'guest';

    /**
     * constructor
     *
     * possible option keys (CASE-SENSITIVE!):
     * - enabled        bool     enable check on predispatch
     * - acl            Zend_Acl Acl object. Can't be passed via Zend_Config
     * - aclModel       mixed    Model instance. Can't be passed via Zend_Config
     * - aclModelClass  string   Class name of lazy loaded model
     * - aclModelParams array    Constructor params to pass during lazy load
     * - loginPage      array    Where to redirect if auth is required
     * - deniedPage     array    Where to redirect if access is denied
     * - guestRole      string   Role to assign if not authenticated
     *
     * @param array|Zend_Config $options
     */
    public function __construct($options = array())
    {
        if (!empty($options)) {
            $this->setOptions($options);
        }
    }

    /**
     * Proxy to isAllowed()
     *
     * @return bool
     */
    public function direct($username, $resource = null, $privilege = null)
    {
        return $this->isAllowed($username, $resource, $privilege);
    }

    /**
     * Check if current user is allowed to access this action before proceeding.
     *
     * If user is not allowed, she is redirected to the login page if she is not
     * authenticated, or to an error page if she is.
     */
    public function preDispatch()
    {
        if (!$this->_enabled) {
            return;
        }
        $auth = Zend_Auth::getInstance();
        $loggedIn = $auth->hasIdentity();
        if ($loggedIn) {
            $user = $auth->getIdentity();
            // it is the responsibility of the auth adapter or controller
            // to convert the identity into an array or object with
            // ArrayAccess interface
            $username = $user['username'];
            $roles = $user['roles'];
        } else {
            $username = 'unimportant but should be unique';
            $roles = array();
        }
        // push the current user's immediate roles to the acl model
        $model = $this->getAclModel();
        $model->addUserRole($username, $roles);

        // generate resource id and priv from current request params
        list($resource, $privilege) = $model->getResource();

        if (!$this->isAllowed($username, $resource, $privilege)) {
            $page = !$loggedIn ? $this->_loginPage : $this->_deniedPage;
            $this->_redirectTo($page);
        }
    }

    /**
     * Adds all the roles (and all the parent roles of each role) to the acl,
     * adds the resource to the acl, builds access rules, then checks if the
     * user is allowed to access the resource.
     *
     * @param string      $username  Username of the user to check
     * @param null|string $resId     Resource id
     * @param null|string $privilege Privilege
     * @return bool Whether the user is allowed access to the resource or not
     */
    public function isAllowed($username, $resId = null, $privilege = null)
    {
        $acl = $this->getAcl();
        $model = $this->getAclModel();

        $userRoleId = self::usernameAsRole($username);
        if (!$acl->hasRole($userRoleId)) {
            $userRoles = $model->getUserRolesByUsername($username);
            if (empty($userRoles)) {
                $userRoles = array($this->_guestRole);
            }
            $this->_addUserRoles($userRoles);
            $acl->addRole(new Zend_Acl_Role($userRoleId), $userRoles);
            Admin_Bootstrap::log('role: ' . $userRoleId);
        }

        // if the resource is already registered, it is assumed that the
        // permissions have been added as well, so no further processing is done
        // TODO: find out what has() returns if param is null
        if (!$acl->has($resId)) {
            $this->_buildPermissions($resId);
        }

        return $acl->isAllowed($userRoleId, $resId, $privilege);
    }

    /**
     * Transform username to a role id.
     *
     * This is needed so that you don't inadvertently allow a user named 'mod'
     * to access mod-only resources even though he's not really a mod.
     *
     * @param string $username
     * @return string
     */
    static public function usernameAsRole($username)
    {
        return md5($username);
    }

    /**
     * Finds all rules associated with $resource and adds them to the acl object
     *
     * @param mixed $resId Resource value compatible with the acl model
     * @todo Find all rules for NULL resource id and add them first.
     */
    protected function _buildPermissions($resId)
    {
        $acl = $this->getAcl();
        $model = $this->getAclModel();

        // add global rules first (i.e. rules with resId == null)
        $globalRules = $model->getRulesByResourceId(null);
        $this->_addRules($globalRules);

        $rules = $model->getRulesByResourceId($resId);

        if (null !== $resId && !$acl->has($resId)) {
            $acl->add(new Zend_Acl_Resource($resId));
        } else {
            return;
        }

        $this->_addRules($rules, $resId);
    }

    protected function _addRules($rules, $resId = null)
    {
        if (in_array($resId, array('', '*'))) {
            $resId = null;
        }

        $acl = $this->getAcl();

        // allow all if no rule is set for the resource
        if (empty($rules) && null !== $resId) {
            $acl->allow(null, $resId);
        } else {
            foreach ($rules as $rule) {
                $roleId = '' !== $rule['role_id'] ? $rule['role_id'] : null;
                $allow = isset($rule['allowed']) ? $rule['allowed'] : true;
                $priv = empty($rule['privilege']) || $rule['privilege'] == '*'
                      ? null : $rule['privilege'];

                if (!empty($roleId) && !$acl->hasRole($roleId)) {
                    // if the role for this rule hasn't been added, it means
                    // this rule is not relevant to the current user.
                    // should it be added anyway?
                    continue;
                    /*
                    $role = $model->getRoleById($roleId);
                    $parents = $this->_addAllAncestorRoles($role);
                    $acl->addRole(new Zend_Acl_Role($roleId), $parents);
                     */
                }

                $action = $allow ? 'allow' : 'deny';
                $acl->$action($roleId, $resId, $priv);

                $resId = null === $resId ? 'NULL' : $resId;
                $priv = null === $priv ? 'NULL' : $priv;
                Admin_Bootstrap::log("rule: $action : $roleId : $resId : $priv");
            }
        }
    }

    /**
     * Iterates through the array of roles and adds all the ancestors of each.
     *
     * @param aray $roles Array of role rows
     * @return array containing immediate role ids of the user
     * @todo The model should already transform the rows of roles into an array
     *       of role IDs. Likewise for the parent roles.
     */
    protected function _addUserRoles($roles)
    {
        $acl = $this->getAcl();
        foreach ($roles as $roleId) {
            $parents = $this->_addAllAncestorRoles($roleId);
            $acl->hasRole($roleId) or Admin_Bootstrap::log('role: ' . $roleId);
            $acl->hasRole($roleId) or $acl->addRole(new Zend_Acl_Role($roleId), $parents);
        }
    }

    /**
     * Recursively add all parent roles of a role
     *
     * @param array    $childId Array of roles
     * @return array containing the ids of immediate parents of $role
     */
    protected function _addAllAncestorRoles($childId)
    {
        $acl = $this->getAcl();
        $model = $this->getAclModel();
        $parents = $model->getParentRoles($childId);
        
        foreach ($parents as $parentId) {
            $grandParents = $this->_addAllAncestorRoles($parentId);
            $acl->hasRole($parentId) or Admin_Bootstrap::log('role: ' . $parentId);
            $acl->hasRole($parentId) or $acl->addRole(new Zend_Acl_Role($parentId), $grandParents);
        }

        return $parents;
    }

    /**
     * Catch-all option setter
     *
     * @param string $option
     * @param mixed  $value
     */
    public function __set($option, $value)
    {
        $property = '_' . strtolower($option[0]) . substr($option, 1);
        if (property_exists($this, $property)) {
            if (is_array($this->$property) && is_array($value)) {
                $this->$property = $value + $this->$property;
            } else {
                $this->$property = $value;
            }
        }
    }

    /**
     * Set options.
     *
     * @param Zend_Config|array $options
     * @return Admin_Helper_AccessManager
     * @throws Admin_Model_Acl_Exception If something other than array or
     *                                   Zend_Config was passed.
     */
    public function setOptions($options)
    {
        if ($options instanceof Zend_Config) {
            $options = $options->toArray();
        } else if (!is_array($options)) {
            throw new Admin_Model_Acl_Exception('Invalid options passed to AccessManager. Must be an array or Zend_Config object.');
        }

        foreach ($options as $opt => $val) {
            $setter = 'set' . $opt;
            if (method_exists($this, $setter)) {
                $this->$setter($val);
            } else {
                $this->__set($opt, $val);
            }
        }

        return $this;
    }

    /**
     * Return value of protected members
     *
     * @param string $key
     * @param mixed  $default
     * @return mixed
     */
    public function getOption($key, $default = null)
    {
        $key = strtolower($key[0]) . substr($key, 1);
        $property = '_' . $key;
        return isset($this->$property) ? $this->$property : $default;
    }

    /**
     * enable check on predispatch
     * @return Admin_Helper_AccessManager
     */
    public function enable()
    {
        $this->_enabled = true;
        return $this;
    }

    /**
     * disable check on predispatch
     * @return Admin_Helper_AccesssManager
     */
    public function disable()
    {
        $this->_enabled = false;
        return $this;
    }

    /**
     * Lazy load Acl object
     *
     * @return Zend_Acl
     */
    public function getAcl()
    {
        if (null === $this->_acl) {
            $this->_acl = new Zend_Acl();
        }
        return $this->_acl;
    }

    /**
     * Set Acl object
     *
     * @param Zend_Acl $acl
     * @return Admin_Bootstrap
     */
    public function setAcl(Zend_Acl $acl)
    {
        $this->_acl = $acl;
        return $this;
    }

    /**
     * The acl model is the object from which the user roles and permissions
     * are pulled from
     *
     * @return Admin_Model_Acl
     */
    public function getAclModel()
    {
        if (null === $this->_aclModel) {
            $this->_aclModel = call_user_func_array(
                array(new ReflectionClass($this->_aclModelClass), 'newInstance'),
                $this->_aclModelParams);
        }
        return $this->_aclModel;
    }

    /**
     * Set the acl model
     *
     * @param mixed $aclModel
     * @return Admin_Helper_AccessManager
     */
    public function setAclModel($aclModel)
    {
        $this->_aclModel = $aclModel;
        return $this;
    }

    /**
     * Set request params and repeat dispatch loop.
     * 
     * @param array $page MUST have the keys 'module', 'controller' and 'action'
     */
    protected function _redirectTo($page)
    {
        $req = $this->getRequest();
        $orig = $req->getParams();
        $req->setModuleName($page['module'])
            ->setControllerName($page['controller'])
            ->setActionName($page['action'])
            ->setParam('originParams', $orig)
            ->setDispatched(false);

        $flash = $this->getActionController()->getHelper('FlashMessenger');
        $flash->setNamespace('user')
              ->addMessage('Authentication required to access this page.');
    }


}
